4.4-数据通信
Create by fall on 2021-09-22 Recently revised in 2022-01-27
文件处理
前端的文件处理包括,上传,类型检查,文件类型转换,二进制处理,文件下载
上传文件
场景:单文件、多文件、目录上传、压缩目录上传、拖拽上传、剪贴板上传、大文件分块上传、服务端上传。
单文件上传
前端代码
<input id='uploadFile' type="file" accept="image/*"/>
<button id='submit' onclick='uploadFile()'>上传文件</button>
其中 accept 用于限制上传文件类型。IE9 以下不支持该属性(IE 已死)
image/*
限制只能上传 image 文件image/png
限制只能上传pngimage/png,image/jpeg
限制只能上传png和jpg
accept只进行限制后缀名,把后缀名更改还是可以绕过检测,而想实现只接受特定的格式,使用其他工具,或者是看这个教程 如何区分图片类型。
JS代码
const uploadFileEle = document.querySelector('#uploadFile')
const request = axios.create({
baseURL:'http://localhost:8080/upload',
timeout:60000
})
// 判断是否存在文件,获取文件,进行上传
async functioin uploadFile(){
if(!uploadFileEle.files.length) return
const file = uploadFileEle.files[0]
upload({
url:'/single',
file
})
}
function upload({url,file,fieldName='file'}){
let formData = new FormData()
formData.set(fieldName,file)
request.post(url,formData,{
onPuloadProgress:function(progressEvent){
const percentCompleted = Math.round(
(progressEvent.loaded*100)/progressEvent.total)
}
console.log(percentCompleted)
})
}
上述代码先把读取的 File 对象封装成FormData对象,然后利用 Axios 的 post 实现文件上传的功能。再上传之前,通过请求配置对象的onUploadProgress 就可以获取文件的上传进度。
大文件上传
现在来看看在上面提到的几种上传方式中实现大文件上传会遇见的超时问题,
- 表单上传和iframe无刷新页面上传,实际上都是通过form标签进行上传文件,这种方式将整个请求完全交给浏览器处理,当上传大文件时,可能会遇见请求超时的情形
- 通过fromData,其实际也是在xhr中封装一组请求参数,用来模拟表单请求,无法避免大文件上传超时的问题
- 编码上传,我们可以比较灵活地控制上传的内容
大文件上传最主要的问题就在于:在同一个请求中,要上传大量的数据,导致整个过程会比较漫长,且失败后需要重头开始上传。试想,如果我们将这个请求拆分成多个请求,每个请求的时间就会缩短,且如果某个请求失败,只需要重新发送这一次请求即可,无需从头开始,这样是否可以解决大文件上传的问题呢?
综合上面的问题,看来大文件上传需要实现下面几个需求
- 支持拆分上传请求(即切片)
- 支持断点续传
- 支持显示上传进度和暂停上传
接下来让我们依次实现这些功能,看起来最主要的功能应该就是切片了。
文件切片
编码方式上传中,在前端我们只要先获取文件的二进制内容,然后对其内容进行拆分,最后将每个切片上传到服务端即可。
在JavaScript中,文件FIle对象是Blob对象的子类,Blob对象包含一个重要的方法slice,通过这个方法,我们就可以对二进制文件进行拆分。
下面是一个拆分文件的示例
function slice(file, piece = 1024 * 1024 * 5) {
let totalSize = file.size; // 文件总大小
let start = 0; // 每次上传的开始字节
let end = start + piece; // 每次上传的结尾字节
let chunks = []
while (start < totalSize) {
// 根据长度截取每次需要上传的数据
// File对象继承自Blob对象,因此包含slice方法
let blob = file.slice(start, end);
chunks.push(blob)
start = end;
end = start + piece;
}
return chunks
}
将文件拆分成piece大小的分块,然后每次请求只需要上传这一个部分的分块即可
let file = document.querySelector("[name=file]").files[0];
const LENGTH = 1024 * 1024 * 0.1;
let chunks = slice(file, LENGTH); // 首先拆分切片
chunks.forEach(chunk=>{
let fd = new FormData();
fd.append("file", chunk);
post('/mkblk.php', fd)
})
服务器接收到这些切片后,再将他们拼接起来就可以了,下面是PHP拼接切片的示例代码
$filename = './upload/' . $_POST['filename'];//确定上传的文件名
//第一次上传时没有文件,就创建文件,此后上传只需要把数据追加到此文件中
if(!file_exists($filename)){
move_uploaded_file($_FILES['file']['tmp_name'],$filename);
}else{
file_put_contents($filename,file_get_contents($_FILES['file']['tmp_name']),FILE_APPEND);
echo $filename;
}
测试时记得修改nginx的server配置,否则大文件可能会提示413 Request Entity Too Large的错误。
server {
// ...
client_max_body_size 50m;
}
上面这种方式来存在一些问题
- 无法识别一个切片是属于哪一个切片的,当同时发生多个请求时,追加的文件内容会出错
- 切片上传接口是异步的,无法保证服务器接收到的切片是按照请求顺序拼接的
因此接下来我们来看看应该如何在服务端还原切片。
还原切片
在后端需要将多个相同文件的切片还原成一个文件,上面这种处理切片的做法存在下面几个问题
- 如何识别多个切片是来自于同一个文件的,这个可以在每个切片请求上传递一个相同文件的context参数
- 如何将多个切片还原成一个文件
- 确认所有切片都已上传,这个可以通过客户端在切片全部上传后调用mkfile接口来通知服务端进行拼接
- 找到同一个context下的所有切片,确认每个切片的顺序,这个可以在每个切片上标记一个位置索引值
- 按顺序拼接切片,还原成文件
上面有一个重要的参数,即context,我们需要获取为一个文件的唯一标识,可以通过下面两种方式获取
- 根据文件名、文件长度等基本信息进行拼接,为了避免多个用户上传相同的文件,可以再额外拼接用户信息如uid等保证唯一性
- 根据文件的二进制内容计算文件的hash,这样只要文件内容不一样,则标识也会不一样,缺点在于计算量比较大.
修改上传代码,增加相关参数
// 获取context,同一个文件会返回相同的值
function createContext(file) {
return file.name + file.length
}
let file = document.querySelector("[name=file]").files[0];
const LENGTH = 1024 * 1024 * 0.1;
let chunks = slice(file, LENGTH);
// 获取对于同一个文件,获取其的context
let context = createContext(file);
let tasks = [];
chunks.forEach((chunk, index) => {
let fd = new FormData();
fd.append("file", chunk);
// 传递context
fd.append("context", context);
// 传递切片索引值
fd.append("chunk", index + 1);
tasks.push(post("/mkblk.php", fd));
});
// 所有切片上传完毕后,调用mkfile接口
Promise.all(tasks).then(res => {
let fd = new FormData();
fd.append("context", context);
fd.append("chunks", chunks.length);
post("/mkfile.php", fd).then(res => {
console.log(res);
});
});
在mkblk.php接口中,我们通过context来保存同一个文件相关的切片
// mkblk.php
$context = $_POST['context'];
$path = './upload/' . $context;
if(!is_dir($path)){
mkdir($path);
}
// 把同一个文件的切片放在相同的目录下
$filename = $path .'/'. $_POST['chunk'];
$res = move_uploaded_file($_FILES['file']['tmp_name'],$filename);
除了上面这种简单通过目录区分切片的方法之外,还可以将切片信息保存在数据库来进行索引。接下来是mkfile.php接口的实现,这个接口会在所有切片上传后调用
// mkfile.php
$context = $_POST['context'];
$chunks = (int)$_POST['chunks'];
//合并后的文件名
$filename = './upload/' . $context . '/file.jpg';
for($i = 1; $i <= $chunks; ++$i){
$file = './upload/'.$context. '/' .$i; // 读取单个切块
$content = file_get_contents($file);
if(!file_exists($filename)){
$fd = fopen($filename, "w+");
}else{
$fd = fopen($filename, "a");
}
fwrite($fd, $content); // 将切块合并到一个文件上
}
echo $filename;
这样就解决了上面的两个问题:
- 识别切片来源
- 保证切片拼接顺序
断点续传
即使将大文件拆分成切片上传,我们仍需等待所有切片上传完毕,在等待过程中,可能发生一系列导致部分切片上传失败的情形,如网络故障、页面关闭等。由于切片未全部上传,因此无法通知服务端合成文件。这种情况下可以通过断点续传来进行处理。
断点续传指的是:可以从已经上传部分开始继续上传未完成的部分,而没有必要从头开始上传,节省上传时间。
由于整个上传过程是按切片维度进行的,且mkfile接口是在所有切片上传完成后由客户端主动调用的,因此断点续传的实现也十分简单:
- 在切片上传成功后,保存已上传的切片信息
- 当下次传输相同文件时,遍历切片列表,只选择未上传的切片进行上传
- 所有切片上传完毕后,再调用mkfile接口通知服务端进行文件合并
因此问题就落在了如何保存已上传切片的信息了,保存一般有两种策略
- 可以通过locaStorage等方式保存在前端浏览器中,这种方式不依赖于服务端,实现起来也比较方便,缺点在于如果用户清除了本地文件,会导致上传记录丢失
- 服务端本身知道哪些切片已经上传,因此可以由服务端额外提供一个根据文件context查询已上传切片的接口,在上传文件前调用该文件的历史上传记录
下面让我们通过在本地保存已上传切片记录,来实现断点上传的功能
// 获取已上传切片记录
function getUploadSliceRecord(context){
let record = localStorage.getItem(context)
if(!record){
return []
}else {
try{
return JSON.parse(record)
}catch(e){}
}
}
// 保存已上传切片
function saveUploadSliceRecord(context, sliceIndex){
let list = getUploadSliceRecord(context)
list.push(sliceIndex)
localStorage.setItem(context, JSON.stringify(list))
}
然后对上传逻辑稍作修改,主要是增加上传前检测是已经上传、上传后保存记录的逻辑
let context = createContext(file);
// 获取上传记录
let record = getUploadSliceRecord(context);
let tasks = [];
chunks.forEach((chunk, index) => {
// 已上传的切片则不再重新上传
if(record.includes(index)){
return
}
let fd = new FormData();
fd.append("file", chunk);
fd.append("context", context);
fd.append("chunk", index + 1);
let task = post("/mkblk.php", fd).then(res=>{
// 上传成功后保存已上传切片记录
saveUploadSliceRecord(context, index)
record.push(index)
})
tasks.push(task);
});
此时上传时刷新页面或者关闭浏览器,再次上传相同文件时,之前已经上传成功的切片就不会再重新上传了。
服务端实现断点续传的逻辑基本相似,只要在getUploadSliceRecord内部调用服务端的查询接口获取已上传切片的记录即可,因此这里不再展开。
此外断点续传还需要考虑切片过期的情况:如果调用了mkfile接口,则磁盘上的切片内容就可以清除掉了,如果客户端一直不调用mkfile的接口,放任这些切片一直保存在磁盘显然是不可靠的,一般情况下,切片上传都有一段时间的有效期,超过该有效期,就会被清除掉。基于上述原因,断点续传也必须同步切片过期的实现逻辑。
上传进度和暂停
通过xhr.upload中的progress方法可以实现监控每一个切片上传进度。
上传暂停的实现也比较简单,通过xhr.abort可以取消当前未完成上传切片的上传,实现上传暂停的效果,恢复上传就跟断点续传类似,先获取已上传的切片列表,然后重新发送未上传的切片。
由于篇幅关系,上传进度和暂停的功能这里就先不实现了。
应用场景
保存文件
const onSaveFile = () => {
const blob = new Blob([codeContext.value], { type: 'text/javascript' });
const url = URL.createObjectURL(blob)
const a = document.createElement("a");
a.download = "sunshine";
a.href = url;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
}
分片上传
分片上传:对于大文件的上传,通过 slice 的方法对大文件进行切割,然后分片进行上传
const file = new File(['a'.repeat(1000000)],'test.txt')
const chunkSize = 40000
const postUrl = 'https://example.web/post'
async function chunkUpload(){
for(let start = 0;start<file.size;start+=chunkSize){
const chunk = file.slice(start,start+chunkSize+1)
const fd = new FormData()
fd.append('data',chunk)
await fetch(postUrl,{method:'post',body:fd}).then(res=>{
res.text()
})
}
}
下载数据
下载数据:可以将从互联网上下载的数据存放到 Blob 对象中
// 使用 XMLHttpRequest
const downloadBlob = (url,callback)=>{
const xhr = new XMLHttpRequest()
xhr.open('GET',url)
xhr.responseType = 'blob'
xhr.onload=()=>{
callback(xhr.response)
}
xhr.send(null)
}
// 使用 fetch
const myImg = document.querySelector('img')
const myReq = new Request('flowers.jpg')
fetch(myReq).then(res=>{
return response.blob()
}).then(blob=>{
let objURL = URL.createObjectURL(blob)
myImage.src = objURL
})
导出为 Excel
导入文件:
- 用户上传文件,前端 FileReader 读取文件为二进制字符串
- 解析二进制字符串为 excel 工作簿对象
- XLSX.utils.sheet_to_json()方法,从工作簿对象中获取第一张 Sheets 表格数据并将其转换为 json 数据。
导出文件:
- XLSX.utils.book_new() 创建工作簿对象
需要用到 xlsx 插件
import xlsx from 'xlsx'
export const exportJsonToExcel = ({
header,
data,
key,
title,
filename,
autoWidth
}) => {
const wb = XLSX.utils.book_new()
if (header) {
data.unshift(header)
}
if (title) {
data.unshift(title)
}
const ws = XLSX.utils.json_to_sheet(data, {
header: key,
skipHeader: true
})
if (autoWidth) {
const arr = json_to_array(key, data)
auto_width(ws, arr)
}
xlsx.utils.book_append_sheet(wb, ws, filename)
xlsx.writeFile(wb, filename + '.xlsx')
}
function importExcel (file, tableTemplate) {
return new Promise((resolve, reject) => {
let f = file.raw // 获取文件内容
// 通过DOM取文件数据
let rABS = false // 是否将文件读取为二进制字符串
let reader = new FileReader()
FileReader.prototype.readAsBinaryString = function (f) {
let binary = ''
let rABS = false // 是否将文件读取为二进制字符串
let wb // 读取完成的数据
let outdata
let reader = new FileReader()
reader.onload = function (e) {
let bytes = new Uint8Array(reader.result)
let length = bytes.byteLength
for (let i = 0; i < length; i++) {
binary += String.fromCharCode(bytes[i])
}
let XLSX = require('xlsx')
if (rABS) {
wb = XLSX.read(btoa(binary), { // 手动转化
type: 'base64'
})
} else {
wb = XLSX.read(binary, {
type: 'binary'
})
}
outdata = XLSX.utils.sheet_to_json(wb.Sheets[wb.SheetNames[0]]) // outdata就是表格中的值
let arr = []
// 下面是数据解析提取逻辑
if (tableTemplate.length > 0) {
let tempArr = Object.keys(outdata[0])
let tempArrNew = []
for (let i in tempArr) {
for (let k in tableTemplate) {
if (i === k) {
tempArrNew.push({fieldE: tableTemplate[k], fieldC: tempArr[i]})
}
}
}
tempArr = tempArrNew
outdata.map(item => {
let obj = {}
tempArr.map(temp2 => {
obj[temp2.fieldE] = item[temp2.fieldC]
})
arr.push(obj)
})
}
resolve(arr)
}
reader.readAsArrayBuffer(f)
}
if (rABS) {
reader.readAsArrayBuffer(f)
} else {
reader.readAsBinaryString(f)
}
})
}